Webpack 之入木三分(一)


前言

webpack 是目前最火的项目构建工具,只需要简单的配置,就能够完成对模块的加载和打包。这篇文章意在让我从繁杂的文档中解脱出来。

不跟你多bb,先看 Webpack 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
mode: "production", // "production" | "development" | "none"
devtool: "cheap-module-eval-source-map", // development | "cheap-module-source-map" production
entry: "./src/index.js", // string | object | array
// 默认为 ‘./src’
// 应用程序开始执行
// webpack 开始打包
output: {
// webpack 如何输出结果的相关选项
path: path.resolve(__dirname, 'dist'), // string
filename: "[chunkhash.js]", // 用于长效缓存 | “[name.js]” 用于多个入口点 | string
publicPath: "/assets/", // string 输出解析文件的目录
chunkFilename: "[name].chunk.js", // 单独分割第三方库进行长效缓存
...
},
module: {
// 关于模块配置
rules: [
// 配置相关loaders,解析规则
{
test: /\.(jpe?g|png|gif)(\?.*)?$/,
use: {
loader: 'url-loader',
// 此 loader 只会去编译 html 和 css 中的 image,若是在 vue 中 用 v-for 遍历 data 图片列表的话就不能使用 base64 编码
options: { // loader 的可选项
name: '[name]_[hash].[ext]',
outputPath: 'images/',
limit: 10240
}
}
},
{
test: /\.(eot|woff|ttf|svg)$/,
use: {
loader: 'file-loader' // 此 loader 与上一个 loader 差别是不能设置图片大小
}
},
{
test: /\.scss$/, // loader 解析由自下而上,自右向左。
use: [
'style-loader', // 将 JS 字符串生成为 style 节点
{
loader: 'css-loader', // 将 CSS 转化成 CommonJS 模块
options: {
importLoaders: 2 // 表示该 loader 后的 执行 loader 数量
}
},
'sass-loader', // 将 Sass 编译成 CSS
'postcss-loader' // 给 css 加浏览器前缀适配浏览器
]
},
{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /\.jsx?$/,
exclude: /node_modules/, // 必不匹配选项(优先级高于 test 和 include)
loader: 'babel-loader' // 获取兼容浏览器的 JavaScript 代码
}
...
]
},
performance: { // object
hints: 'warninig' // 将展示一条警告,通知你这是体积大的资源。推荐开发环境
},
optimization: { // 优化
splitChunks: { // 分块策略
chunks: 'all', // 允许同步和异步 | initial | async (默认) | all (推荐)
minSize: 30000, // 形成一个新代码块最小的体积
maxSize: 0, // 超过此大小一般来说会再此被分割成文件
minChunks: 1, // 在分割之前,这个代码块最小应该被引用的次数,默认策略是不需要多次引用也可以被分割
maxAsyncRequests: 5, // 按需加载时候最大的并行请求数
maxInitialRequests: 3, // 一个入口最大的并行请求数
automaticNameDelimiter: '~', // 打包分隔符
name: true, // 打包后的名称,此选项可接收 function
cacheGroups: { // 缓存组
vendors: { // key 为entry中定义的 入口名称
test: /[\\/]node_modules[\\/]/, // 用于控制哪些模块被这个缓存组匹配到。原封不动传递出去的话,它默认会选择所有的模块。可以传递的值类型:RegExp、String和Function
priority: -10, // 缓存组打包的先后优先级
// filename: 'vendors' 要缓存的 分隔出来的 chunk 名称
},
default: { // 这里是表示不在 node_modules 中的分割块
minChunks: 2,
priority: -20,
reuseExistingChunk: true // 可设置是否重用该chunk
}
}
}
},
plugins: [
new HtmlWebpackPlugin({
// 生成一个 HTML5 文件, 其中包含使用 script 标签的 body 中的 所有 webpack 包。如果有多个 webpack 入口点,他们都会在生成的 HTML 文件中的 script 标签内。
title: '学习 webpack',
template: 'src/index.html',
minify: {
collapseWhitespace: true // 压缩,去掉所有空格
},
hash: true // 添加 hash
}),
new CleanWebpackPlugin() // 删除文件,保留新文件
]
}

在实际开发当中,是要区分开发环境和生产环境。大致目录:

1
2
3
+ build
+ config
+ src

在 build 目录下有三个 webpack 配置,分别是:

  • webpack.base.conf.js
  • webpack.dev.conf.js
  • webpack.prod.conf.js

这分别对应开发、生产和测试环境的配置。其中 webpack.base.conf.js 是一些公共的配置项。我们使用 webpack-merge 把这些公共配置项和环境特定的配置项 merge 起来,成为一个完整的配置项。比如 webpack.dev.conf.js 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const webpack = require('webpack');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base.conf.js');

const devConfig = {
mode: "development",
devtool: "cheap-module-eval-source-map",
devServer: {
contentBase: './dist', // 告诉服务器从哪里提供内容。只有在你想要提供静态文件时才需要。
open: true, // 自启动默认浏览器
hot: true, //启用 webpack 的热模块更换功能
hotOnly: true, // 在生成失败的情况下,启用热模块替换(请参阅devServer.hot),而不刷新页面作为回退。
host: "0.0.0.0" // 指定要使用的主机,默认 localhost。这里的作用是希望你的服务器允许外部访问
port: 9000,
compress: true, // 为服务的所有内容启用 gzip 压缩
https: true, // 默认情况下,dev-server将通过HTTP提供服务
// https: {
// key: fs.readFileSync("/path/to/server.key"),
// cert: fs.readFileSync("/path/to/server.crt"),
// ca: fs.readFileSync("/path/to/ca.pem"),
// } // 设置签名证书

allowedHosts: [ // array | 该选项允许您将允许访问开发服务器的服务列入白名单
'host.com',
'subdomain.host.com',
'subdomain2.host.com',
'host2.com'
],
proxy: {
// 当你有一个单独的API后端开发服务器,并且你想在同一个域上发送 API 请求时,代理一些 URL 会很有用。
"/api": "http://localhost:3000"
}
},
optimization: {
usedExports: true // 开发环境下配置 tree shaking
},
plugins: [
new webpack.HotModuleReplacementPlugin()
]
}

module.exports = merge(baseConfig, devConfig);

从上面 webpack 配置来看,再配置 .babelrc 文件,使其能够使用新的 ES 语法

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"presets": [
[
"@babel/preset-env", {
"targets": {
"chrome": "67"
},
"useBuiltIns": "usage" // 在此配置后,文件中无需手动添加 @babel/polyfile
}
]
],
"plugins": ["@babel/plugin-syntax-dynamic-import"] // 识别魔法注释
}

再加一个 postcss.config.js 文件

1
2
3
4
5
module.exports = {
plugins: [
require('autoprefixer')
]
}

这些基本满足日常开发需求,若是用 vue 或者 react 开发,再添加相关 loader 就好。

Webpack 高级概念

Tree Shaking

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import 和 export。

不过需要注意的是, tree shaking 能移除无用代码的同时,也有一定的副作用(错误识别无用代码)。比如你可能会遇到 UI 组件库没有样式的问题,这个问题原因在于 tree shaking 不仅对 JS 生效,也对 CSS 生效 。我们通常在导入 CSS 时使用 import ‘xxx.min.css’ , ES6 的静态导入 + 生产环境满足了 tree shaking 的生效条件,并且 Webpack 无法判断 CSS 有效,所以它被当做了 dead-code 然后被删除。为了解决这个问题,你可以在 package.json 中添加一个 sideEffects 选项,告知 Webpack 那些文件是可以直接引入而不用 tree shaking 检查的,使用如下:

package.json

1
2
3
4
5
6
{
"sideEffects": [
"*.css",
"*.styl(us)?"
]
}

示例:创建一个 math.js

1
2
3
4
5
6
export const add = (a, b) => {
console.log(a + b);
}
export const minus = (a, b) => {
console.log(a + b);
}

index.js 中引用它

1
2
3
4
5
// Tree Shaking

import { add } from './math.js';

add(1,2);

然后利用之前的 webpack 配置打包,因为其中 optimization 配置了usedExports。看打包后的 main.js 中是否有多余的 minus 方法。

Code Splitting

它一般做什么:

  • 为 vendor 单独打包 (vendor 指第三方的库或者公共的基础组件,因为 Vendor 的变化比较少,单独打包利于缓存)
  • 为 Manifest (Webpack 的 Runtime 代码)单独打包
  • 为不同入口的公共业务代码打包(同理,也是为了缓存和加载速度)
  • 为异步加载的代码打一个公共的包

在 webpack3 及以前我们都利用 CommonsChunkPlugin 将一些公共代码分割成一个 chunk,实现单独加载。在 webpack4 中 CommonsChunkPlugin 被废弃,使用 SplitChunksPlugin,其配置如上 webpack 所示。

有一点要知道其实 Code Splitting 与 webpack 是无关的,因为不需要 webpack 依然可以做代码分割。之所以用到它是因为更加方便。

示例:index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 我现在想引入一个 lodash 工具库,第一种方式是直接 import 它(静态引入)。
import _ from 'lodash'; 1mb

... 业务逻辑 1mb
console.log(_.join(['a','b'], '*'))
// 这种方式,首次访问页面时,加载 main.js 假设打包后是 2mb,当业务逻辑发生改变时,又要加载 2mb 的内容

第二种方式
那我不用 splitChunksPlugin 配置呢,那就先创建一个新的文件 lodash.js,引入 lodash ,再付给全局变量 window,再到 webpack 配置中配置一下入口文件加一个入口。如此 main.js 就被拆成两个文件 lodash.js、main.js,当业务逻辑发生改变时,只需加载 main.js 即可 (1mb)

第三种方式
在 webpack 中的 optimization 加上 splitChunksPlugin。
optimization: {
splitChunks: {
chunks: 'all'
}
}
这里打包后 dist 目录就会增加一个 vendors~main.js 文件。

第四种方式
当然除了设置 webpack 之外,还有一种是异步引入 lodash,这种方式打包后也会生成一个单独的文件类似 0.js
例如: index.js 中是这样
function getComponent() {
return import('lodash').then(({ default: _ }) => {
var element = document.createElement('div');
element.innerHTML = _.join(['hello', 'world'], ',');
return element;
})
}

getComponent().then(element => {
document.body.appendChild(element);
})
当然也可以用 async / await 代替 promise。

Lazy Loading 懒加载

按需加载又名懒加载,是指当需要依赖的页面被打开采取加载这个依赖,这样就减少了主页的负担,提升首屏渲染速度。而要做到按需加载,你只需在导入依赖的时候用 import 或 require.ensure 这两种动态加载方式。

在这一点上可以根据上面的代码稍微改动下如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async function getComponent() {
const { default: _ } = await import(/* webpackChunkName:"lodash" */ "lodash");
const element = document.createElement('div');
element.innerHTML = _.join(['a', 'b', 'c'], '.');
return element;
}

document.addEventListener('click', () => {
getComponent().then(element => {
document.body.appendChild(element);
})
})

如此刚开始进入页面的时候或页面刷新的时候并不需要加载 lodash,这样就提高了页面的访问速度。在 vue 和 react 中的路由设置也是 import 引入页面组件的,只有当访问到此间页面才会加载其中的组件和资源。这即是懒加载的功能。

打包分析,Preloading,Prefetching

打包分析

推荐使用 webpack-bundle-analyzer ,它会启动一个服务,在浏览器中很清楚地展现生成物和源文件的映射关系和层级,也可以在 package.json 中加上

1
webpack --profile --json > stats.json // 意思是把配置信息存进 stats.json 中

安装

1
npm install --save-dev webpack-bundle-analyzer

配置:在 webpack.prod.conf.js 中增加以下配置

1
2
3
4
5
6
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}

在 项目的 package.json 文件中注入如下命令,以方便运行她(npm run analyz),默认会打开http://127.0.0.1:8888作为展示。

1
“analyz”: “NODE_ENV=production npm_config_report=true npm run build”

运行 npm run build,如图所示

Prefetch
在声明 import 时,使用下面这些内置指令,可以让 webpack 输出 “resource hint(资源提示)”,来告知浏览器

  • prefetch(预取):将来某些导航下可能需要的资源
  • preload(预加载):当前导航下可能需要资源

下面这个 prefetch 的简单示例中,有一个 HomePage 组件,其内部渲染一个 LoginButton 组件,然后在点击后按需加载 LoginModal 组件。

LoginButton.js

1
import(/* webpackPrefetch: true */ 'LoginModal');

这会生成 并追加到页面头部,指示着浏览器在闲置时间预取 login-modal-chunk.js 文件。

与 prefetch 指令相比,preload 指令有许多不同之处:

- preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
- preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。
- preload chunk 会在父 chunk 中立即请求,用于当下时刻。prefetch chunk 会用于未来的某个时刻。
- 浏览器支持程度不同。

CSS 文件的代码分割

首先安装 MiniCssExtractPlugin

1
2
npm install --save-dev mini-css-extract-plugin  // 打包 css
npm install --save-dev optimize-css-assets-webpack-plugin // 压缩 css 压缩是生产环境下的优化,开发环境去设置它反而会影响到热加载性能、适得其反

webpack.prod.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
module.exports = {
plugins: [
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: "[name].css",
chunkFilename: "[id].css"
})
],
optimization: {
minimizer: [new OptimizeCSSAssetsPlugin({})]
},
module: {
rules: [
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader, // 这里不能用 style-loader,同理 scss 等线上环境的打包都用此种方式。
options: {
// you can specify a publicPath here
// by default it use publicPath in webpackOptions.output
publicPath: '../'
}
},
"css-loader"
]
}
]
}
}

Webpack 与浏览器缓存(Caching)

当我们每次打包的时候,这里有个问题。在改变源代码的情况下打包后,用户浏览器上有我们之前代码包的缓存,这里我们需要配置一下来更新下代码。

webpack.pord.conf.js

1
2
3
4
5
6
module.exports = {
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].js'
}
}

显而易见,这是根据代码内容变化所产生一个 hash 值,代码不变,文件包名字也不改变,这样就解决了上面的问题。

当然还有可能会遇到一个问题,当我们没有改变这源代码,但是打包后的 hash 值依然改变了,这即是 webpack 的版本太低了,在新版本中会默认有个这样的配置如下:

1
2
3
4
5
6
7
module.exports = {
optimization: {
runtimeChunk: {
name: 'runtime'
}
}
}

首先为什么 hash 值依然改变了,那是因为在我们打包后的 main.js 和 chunk 之间有着关联关系的存在,即 manifest,老版本在打包后 manifest 可能会发生改变。所以造成 hash 跟着变了的结果。上面配置解决的即是把关连关系单独提出一个文件出来为 runtime.[hash].js 这样每次打包的时候主文件和库文件的 hash 问题就解决了。

Shimming 的作用

它的作用意在解决 webpack 打包过程当中的兼容问题,类似 @babel/polyfill 解决的是兼容低版本浏览器中没有 promise 等全局变量的问题。这就是所谓的垫片

先看个🌰:

index.js

1
2
3
4
5
6
7
8
9
import $ from 'jquery';
import _ from 'lodash';
import { ui } from './jquery.ui'

ui();

const dom = $('div');
dom.html(_.join(['dell', 'lee'], '---'));
$('body').append(dom);

jquery.ui.js

1
2
3
export function ui() {
$('body').css('background', 'red');
}

在此 index.js 中要在外部引入 jquery.ui.js 这个库文件,但是这里会有个小问题,jquery.ui.js 在打包后会报一个错 $ is undefined. 显而易见,在ES module 中变量是不能跨文件的,也即是安全变量。这种形式解决了模块与模块之间的耦合。

当然你不可能修改库文件代码,或者去 node_modules 里面去修改代码,在里面引入一个 $ 变量。所以这时候就要用到 shimming。如下所示:

webpack.base.conf.js

1
2
3
4
5
6
7
8
9
module.exports = {
plugins: [

new webpack.ProvidePlugin({
$: 'jquery', // 作用是在发现模块中有 $ 这个字符串的时候,就表示引入了 jquery
_join: ['lodash', 'join'] // shimming 的细粒化,即只用lodash中的 join 方法
})
]
}

shimming 就是帮助修改或者解决 webpack 一些不能解决的事情。又比如,我想在模块中使用 this,同时要使它指向 window。我们知道 模块中的 this 都是指向模块自身。为了得到你想要的 this。可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = {
module: {
rules: [{
test: /\.jsx?$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader'
},
{
loader: 'imports-loader? this=>window'
}
]
}]
}
}